fix(hit-testing): ignore views with non-invertible transforms (scale 0)#56586
fix(hit-testing): ignore views with non-invertible transforms (scale 0)#56586qflen wants to merge 1 commit intofacebook:mainfrom
Conversation
|
Warning JavaScript API change detected This PR commits an update to
This change was flagged as: |
Views with `transform: [{scaleY: 0}]` or `{scaleX: 0}` were still receiving
touches on both iOS and Android. The symptoms looked different: Android
"inherited" a hit region from a sibling view; iOS collapsed the touch point
onto the degenerate axis. Both reduce to the same root cause: when a
transform can't be inverted, the platform APIs silently fall back to stale
or degenerate data during hit testing.
Android: `TouchTargetHelper.getChildPoint` called `Matrix.invert(inverseMatrix)`
without checking its return value. `Matrix.invert` returns false for a
non-invertible matrix and leaves its destination unchanged. Because
`inverseMatrix` is a class-level field reused across every child-point
conversion, a failure meant we applied the previous view's inverse to the
current view's touch point, the exact "inherits from another view" symptom.
It also explains why shrinking a view from `scaleY: 0.9` to `0.0` left a 90%
hit region: the 0.9 frame had populated the cache. Made `getChildPoint` return
Boolean and skip children whose matrix cannot be inverted.
iOS: `CGAffineTransformInvert` returns the original matrix when it can't
invert, so `-[UIView convertPoint:fromView:]` collapses the touch point onto
the degenerate axis and the inherited `pointInside:` still reports a hit.
Added a small `RCTLayerTransformCollapsesAxis` helper in
`RCTViewComponentView.mm` (Fabric) and return NO from the existing
`pointInside:` override when the 2x2 projection of `self.layer.transform`
has determinant ~ 0.
Tests:
* `TouchTargetHelperTest` covers initial scaleX/scaleY: 0, zero-scale parent,
the "inherits from sibling" regression, the 0.9 to 0.0 transition, and the
touch-path accumulator.
* `RCTViewComponentViewTests` gets parallel iOS cases.
* RNTester: added a "Zero-scale hit test" entry under Transforms so this
stays visually verifiable.
Fixes facebook#50797
d3f2974 to
231129a
Compare
|
Thanks for the review, @javache! Pushed an update addressing both points: Reverted the In The four Happy to iterate further if anything else needs tweaking. |
|
@javache has imported this pull request. If you are a Meta employee, you can view this in D102590386. |
|
This pull request was successfully merged by @qflen in 1e8e182 When will my fix make it into a release? | How to file a pick request? |
Summary
Fixes #50797. A view with a non-invertible transform (e.g.
transform: [{scaleY: 0}]or{scaleX: 0}) still received touches on both Android and iOS. On Android the view appeared to "inherit" a hit region from another view in the hierarchy; on iOS the view registered taps along the collapsed axis. Both symptoms collapse to the same native behaviour: when a transform can't be inverted, the platform APIs silently fall back to stale or degenerate data during hit testing.Root cause
Android.
TouchTargetHelper.getChildPointcallsMatrix.invert(inverseMatrix)without checking its return value.Matrix.invertreturnsfalsefor a non-invertible matrix and leaves its destination unchanged.inverseMatrixis a class-level field reused across every child-point conversion, so on failure we apply the previous view's inverse to the current view's touch point: the exact "inherits from another view" behavior. It is also why shrinking a view fromscaleY: 0.9to0.0leaves a 90 % hit region: the 0.9 frame populated the cache.iOS.
CGAffineTransformInvertis documented to return the original matrix when it can't invert.-[UIView convertPoint:fromView:]uses that "inverse" during hit testing, so the touch point gets collapsed onto the degenerate axis (e.g. y = 0 forscaleY: 0) and the inheritedpointInside:still reports a hit against a visually invisible view.Fix
TouchTargetHelper.kt):getChildPointnow returnsBoolean. It returnsfalsewhenMatrix.invertfails; the child iteration loop skips such children. One behaviour change, no epsilon tuning, no shared state leaks. Resolves both symptom variants via the same code path.RCTViewComponentView.mm, Fabric): add a smallRCTLayerTransformCollapsesAxishelper that tests the determinant of the 2 × 2 XY projection oflayer.transformand returnNOfrom the existingpointInside:override when it collapses.TouchTargetHelperTest(new, Robolectric): initialscaleX/scaleY: 0, zero-scale parent, the "inherits from sibling" regression, the 0.9 to 0.0 transition, and the touch-path accumulator.RCTViewComponentViewTestsgets four parallel iOS cases. All pass locally.Why not #53769?
PR #53769 solved the Android side with a
hasZeroScale(view)early-return plus an epsilon comparison on individual matrix entries, and handled theinvertfailure ingetChildPointby falling back to untransformed coordinates. That still lets the degenerate view be hit with its pre-transform bounds; the early-return then has to catch the miss. This branch fixes the root cause once: if the matrix can't be inverted we don't descend into the child. The iOS fix is the matching change on the Fabric side.Changelog:
[GENERAL] [FIXED] - Views with a non-invertible transform (e.g.
scaleX: 0orscaleY: 0) no longer receive touches on Android or iOS.Test Plan
Unit tests
./gradlew :packages:react-native:ReactAndroid:testDebugUnitTest --tests 'com.facebook.react.uimanager.TouchTargetHelperTest*': 7/7 pass.RNTesterUnitTestsscheme succeeds; the four newRCTViewComponentViewTestscases live next to the existing cases inReact/Tests/Mounting/../gradlew ktfmtCheck,clang-format --dry-run -Werror,prettier --check,flow, andeslintall pass.Manual verification (Android)
RNTester → Components → Transforms → "Zero-scale hit test (regression for #50797)".
Variant 1: tap the
scaleY: 0row directly. "Last tapped:" must stay(none).Last tapped: zero-scale(bug)Last tapped: (none)Variant 2: tap the
scaleY: 0.5row (①), then tap thescaleY: 0row (②). Without the fix, the sharedinverseMatrixcache populated by tap ① leaks into the zero-scale hit test on tap ②. After the fix, tap ② is correctly ignored and "Last tapped" still reflects tap ①.Last tapped: zero-scale(stale cache)Last tapped: variable (scaleY=0.5)iOS behaves similarly. I tried the example on iPhone 17 Pro (Simulator, iOS 26.1) and the zero-scale pressable is correctly untouchable; driving synthetic taps on the simulator wasn't scripted here, so the iOS signal is the four XCTest cases plus manual verification.